Skip to content

feat: prefer fewer charging interruptions - #151

Open
andig wants to merge 10 commits into
mainfrom
feat/fewer-charging-interruptions
Open

andig wants to merge 10 commits into
mainfrom
feat/fewer-charging-interruptions

Conversation

@andig

@andig andig commented Sep 5, 2026

Copy link
Copy Markdown
Member

Refs #150

Prefer fewer charging interruptions when the achieved economics and existing strategy objectives can be preserved. This is a cost-neutral preference, not enforcement of evcc's Continuous setting or a guarantee of one session when interrupted charging is cheaper.

  • Add an automatic final tie-break for batteries with a positive minimum charge power.
  • Bound the achieved economic and preference objectives and each leveled grid peak instead of relying on a small objective coefficient.
  • Limit the additional solve to one second within the remaining request budget, retaining the previous schedule unless a valid improvement is found.

cc @ekkea

🤖 Generated with OpenCode

@andig
andig marked this pull request as ready for review September 5, 2026 13:34
The continuity stage validated its candidate with candidate.valid(1e-5), which
re-evaluates every model row from the values CBC wrote to its solution file. CBC
prints eight significant digits, so a 40 kWh SOC comes back rounded to 1e-3 and
the balance rows miss the tolerance by up to 8e-4. The candidate was discarded on
every request with a sizeable EV battery, silently, and the schedule kept its
interruptions. Measured on the request from #146: the candidate found one charge
start at identical cost and was thrown away because 79 rows were off.

CBC already held the model rows within its own tolerance. Check only what this
stage adds, the cost, preference and peak bounds, and the integrality of what
came back. A test with a 40 kWh battery pins it; the existing cases stay below
10 kWh, where eight digits are still enough.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Five solver runs share one clock and the constants that size them sit
across three modules. One table in the README names each stage, when it
runs, and the setting or constant that bounds it, next to the log fields
that report it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
andig added a commit that referenced this pull request Sep 10, 2026
The table already says when the pass runs and what bounds it. The one
sentence the section added, that this is a preference and not a
guarantee, moves into its row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
andig added a commit that referenced this pull request Sep 10, 2026
iseeberg79 added a commit to iseeberg79/optimizer that referenced this pull request Sep 11, 2026
…testing)

Squashed application of the upstream 'feat/fewer-charging-interruptions'
branch (as of e016fe7) on top of our dev, for local evaluation. Not our
own feature — revert this commit if it doesn't hold up or once the
upstream PR lands and we do a real merge instead.

Adds a fifth solve stage after the tie-break: for batteries with c_min > 0
and a fragmented schedule, minimize charge starts under strict bounds on
the achieved cost, preference objective, and each leveled grid peak, so
fewer interruptions never trade away money, existing preferences, or peak
shaping. Bounded to 1s of solver time within the remaining request budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMJZPAoqmpKJEVnPNApipb
@iseeberg79

iseeberg79 commented Sep 14, 2026 •

Copy link
Copy Markdown

Like it. This reduces breaks and helps a lot. I even could imagine to have a small cost factor applied which would not be that wrong but is difficult to define. Happy with the current idea.

@naltatis

Copy link
Copy Markdown
Member

Looks like it would solve the issues in my last two auto-mode planner test runs: evcc-io/evcc#33763 (comment)

@naltatis

Copy link
Copy Markdown
Member

Here the 5-min snapshots from optimizer during my last charging session. It shows the jumping scheduled charging slots between solves. evopt-history.zip

I used the cloud instance that should have this fix preview-deployed already (@andig). So it does not seem to work as intended yet.

@iseeberg79

Copy link
Copy Markdown

There is a chance that equal costs is not true hence the tie break is not applied. Needs to check the logs in detail to be sure because of that.

@andig

andig commented Sep 17, 2026 •

Copy link
Copy Markdown
Member Author

It seems this is not about interruptions but about influencing an equal-cost solve into a particular direction so they remain stable across solves? Which direction would that be and how would be formulate this mathematically?

@andig

andig commented Sep 17, 2026

Copy link
Copy Markdown
Member Author

I analyzed the 108 snapshots from evopt-history.zip (5-minute cadence, 2026-09-16 23:18 → 2026-09-17 08:47) to see what actually changes between solves.

Setup in the data

  • Horizon 48 h, 195 slots, slot 0 truncated to the next quarter (dt[0] ranges 9…899 s).
  • Two storages, both with the same p_a = 0.000172854 €/Wh:
    • car db:17, 68 kWh, c_min 4140 W, c_max 11000 W, s_initial 25160 Wh, s_goal 40800 Wh by 2026-09-17 05:45
    • home battery db:24 (Sungrow), 22.4 kWh
  • Prices: import 0.194 €/kWh 00:00–05:00, 0.3133 €/kWh afterwards; export flat 0.11 €/kWh.

What is stable, what jumps

The block that actually controls charging is stable for four hours of solves: 09-17 03:15–04:45, 17.4 kWh at 11 kW.

What jumps is:

  1. the day-2 (09-18) PV-surplus block assigned to the car,
  2. the residual ~1.5 kWh after 05:00,
  3. the slot-0 command itself.

Cause 1 — the car/home-battery split is degenerate

Two plan families alternate across solves:

family car charge home charge total stored end SoC sum grid import grid export
A 22.9 kWh 34.3 kWh 57.2 kWh 62.6 kWh 20.6 kWh 0
B 32.2 kWh 25.0 kWh 57.2 kWh 62.5 kWh 20.6 kWh 0

Grid export is zero in 100 of 108 solves, so the whole PV surplus is absorbed either way. Total stored energy, grid import and grid export are identical between the families — only the split between car and home battery differs. Since p_a is the same for both storages, the objective cannot distinguish them.

Objective values confirm this. Within each quarter-hour the objective is linear in dt[0], so I regressed it out and extrapolated to dt[0] = 0 for the quarters 00:30–03:00, where the inputs are essentially unchanged (s_initial constant at 25160 Wh):

family normalized objective values
A 1.5809, 1.5780, 1.5802, 1.5786, 1.5786
B 1.5780, 1.5809, 1.5804, 1.5787, 1.5794, 1.5799

Same within ±0.002, with no systematic ordering. The alternation is not one solve finding a better plan — both plans are optimal, and which one comes back is arbitrary.

Cause 2 — price plateaus

All slots 00:00–05:00 carry one price, so any placement of the remaining energy inside that window costs exactly the same. The same holds after 05:00 (all 0.3133 €/kWh), which is why the last 1.5 kWh floats between 05:15, 05:30 and 05:45 across the solves at 05:02, 05:07, 05:12 and 05:23.

The inputs do not explain the jumping

I compared consecutive requests on their shared timestamp grid. 59 consecutive pairs have byte-identical ft, gt, p_N and p_E for every shared slot. 25 of those 59 still returned a different on/off pattern, up to 11 slot flips:

transition on/off flips, identical inputs
03:20 → 03:26 11
05:02 → 05:07 10
03:52 → 03:58 10
04:19 → 04:24 10
05:07 → 05:12 9
03:36 → 03:42 8
04:35 → 04:40 8
07:21 → 07:26 8

So the change originates inside the solve (branching order, incumbent at the cutoff), not in the data.

Effect on the real charging session

Commanded slot-0 power for the car, while it still needed energy:

time 03:15 03:20 03:26 03:31 03:36 03:42 04:30 04:35 04:40 04:46 05:02 05:12
kW 4.14 5.76 0 8.18 4.14 9.71 8.64 6.95 0 11.0 0 0

Full stops are commanded at 03:26, 04:40, 05:02, 05:07, 05:12 and 05:23 — that matches the interruptions you observed.

Why the tie-break in this PR does not bite here

Family A has fewer car blocks than family B (2 versus 3 over the whole horizon), yet family B is returned in roughly 40 of the solves. Two things are visible in the data:

  • the tie-break has no memory of the previous schedule, so nothing anchors solve N to solve N−1 — on a rolling horizon a degenerate optimum simply re-rolls every five minutes;
  • the alternation is across the car/home-battery split, which is a tie between two different storages, not between two schedules of the same one.

A within-solve tie-break alone cannot stabilize this. What the data suggests is needed:

  1. cross-solve anchoring — pass the previous schedule (or at least the currently active state of each storage) into the request and penalize deviation within the already-bounded cost-neutral budget;
  2. a deterministic secondary criterion when two storages share the same p_a, so the surplus split stops flipping (for example prefer the storage that can discharge, or earliest and contiguous).

Side finding

The solve at 2026-09-16 23:31 (evopt-20260916-233115.json) used a different demand series than both its neighbours — gt differs by up to 306 Wh per slot on all 193 shared slots — and returned objective_value 4.4362 with 38.1 kWh scheduled into the car. The next solve, 20 seconds later, is back to the normal series and 23.1 kWh. Looks like a one-off bad demand forecast sample, probably worth a separate look.

🤖 Generated with Claude Code

@andig

andig commented Sep 17, 2026

Copy link
Copy Markdown
Member Author

Since p_a is the same for both storages, the objective cannot distinguish them.

p_a is the end of horizon value for stored energy. There's only one global setting. We could of course split these per battery type. However, there is an additional issue that I have no idea how to address:

  • if we push vehicle before home battery we may not have enough home battery for the night
  • if we push home battery before vehicle you have the risk of the vehicle leaving later leading to unused surplus

You loose either way.

The continuity stage assumed every device enters the horizon switched off, so keeping a
running session on scored the same as stopping it and restarting one slot later. A device
reporting c_initial above zero now enters the horizon on: continuing costs no start,
interrupting costs one, and a plan that only moves the start earlier is worth polishing.
Only the on/off state decides a charge start, so c_active says that directly instead of
making the model derive it from a power value it never uses.
@iseeberg79

iseeberg79 commented Sep 17, 2026 •

Copy link
Copy Markdown

To anchor a solve seems appropriate to me as well. We've had quite comparable situations by the continuous planner if I remember it correct.
Thanks for the approaches!

feat: anchor charge continuity on the running session
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants